在本篇開始之前,讓我再一次說明 Plugin 與 Function 的關係性,這也是初次接觸 Semantic Kernel 的開發者容易混淆的地方,簡單來說 Plugin 可視為一群 Functions 的封裝,就像物件導向中一個類別(class)裡可以提供多個方法(method)的概念,因此實際運行邏輯的是 Function。
而本篇的重點想要來談談 Function 的設計策略,在前一篇文章裡介紹如何設計具傳入參數的 Function ,而假設我們想設計一個 Function 是取回天氣的資訊,這時候做為一個 Function 在設計,究竟該設計成根據特定條件(例如:城市)只傳回符合該條件的唯一資料,又或者是把今日所有城市的天氣資料一次性的傳回呢?
此種策略是指當呼叫 Plugin 的某個功能(function)時,會立即將相關的所有資料一次性的回傳給 LLM,無論這些資料的數量有多少。首先這樣的方式適合在資料量不大且更新頻率相對不高的情況下使用。而這些資料通常會進一步利用短期記憶方式保存在對話記錄的上下文中,可以重覆性的再次利用。LLM 隨時可以根據這批資料生成相關的回應,並且可以減少呼叫 function 次數,降低可能的時間浪費。
以下這個範例,經由一次性呼叫把回傳資料保存在對話歷史中,後續的對話可以重覆參考已保存的資料,而不需要再次呼叫 function。
然而,即便 Function 缺少了 Description Attribute,仍然必須標注 KernelFunction,這是掛載 Plugin 的必要步驟。只有在明確標註 KernelFunction ,kernel 物件才能順利掛載該 Plugin,並確保能正確的呼叫。
public class WeatherPlugin
{
private readonly Dictionary<string, string> _weatherData = new Dictionary<string, string>
{
{ "台北", "悶熱, 35°C" },
{ "台中", "晴朗, 32°C" },
{ "高雄", "炎熱, 35°C" },
{ "台南", "晴朗, 34°C" },
{ "台東", "陰雨, 28°C" }
};
[KernelFunction]
public string GetWeather()
{
Console.WriteLine("GetWeather");//用來觀察是否有呼叫到這個函數
return JsonSerializer.Serialize(_weatherData);
}
}
// use Azure OpenAI
var builder = Kernel.CreateBuilder()
.AddAzureOpenAIChatCompletion(
endpoint: Config.aoai_endpoint,
deploymentName: Config.aoai_deployment,
apiKey: Config.aoai_apiKey);
builder.Plugins.AddFromType<WeatherPlugin>();
Kernel kernel = builder.Build();
// Get the weather information
var weather = await kernel.Plugins.GetFunction("WeatherPlugin", "GetWeather").InvokeAsync(kernel);
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
// Create a chat history and add the weather information
ChatHistory chatHistory = new ChatHistory($"The weather is:\n {weather}");
// Get the answer from the chat completion service
chatHistory.AddUserMessage("想去高雄旅行,適合穿什麼衣服?");
Console.WriteLine(await chatCompletionService.GetChatMessageContentAsync(chatHistory) + "\n\n");
chatHistory.AddUserMessage("改去台東旅行好了,那適合穿什麼衣服?");
Console.WriteLine(await chatCompletionService.GetChatMessageContentAsync(chatHistory) + "\n\n");
輸出結果:
GetWeather
高雄目前的天氣是烈日炎炎,氣溫達到35°C。建議穿輕便透氣的衣物,例如短袖上衣、輕薄的褲子或裙子,以及舒適的鞋子。此外,記得攜帶防曬用品如帽子、太陽眼鏡和防曬霜,以避免陽光直曬。台东的天气通常比较温暖,建议你穿轻便的夏季服装,例如短袖T恤、短裤或轻薄的裙子。此外,带上一件轻薄的外套以防晚上温度下降,或者如果遇到雨天可以备用。记得穿舒适的鞋子,方便在当地走动和游玩!如果计划进行户外活动,别忘了防晒和带上帽子哦!
此種策略是指當呼叫 Plugin 的某個功能(function)時,根據參數條件只回傳經過篩選過後的部份資料,並且通常不保存在對話記錄的上下文中,每次對話有需求時,就會重新再呼叫 function ,這樣的方式相對適合在原始資料量大或更新頻率高的情況,例如RAG的應用,知識庫的資料雖然不一定經常更新,但資料量相對是比較大的,一次性的把所有知識資料置入在 prompt 裡是不太實際的,並且以RAG應用來說也增加了參考資料的雜訊,通常也會造成回應品質的下降,透過每次呼叫 function 讓 LLM 只取得最相關的資料,進而提升結果的精確性。然而這種策略相對會增加呼叫 function 次數及額外花費的時間。
以下這個範例,在連續對話過程中,自動多次呼叫 function。
public class FoodPlugin
{
private readonly Dictionary<string, string> _foodData = new Dictionary<string, string>
{
{ "台北", "牛肉麵, 鼎泰豐小籠包" },
{ "台中", "太陽餅, 大甲芋頭酥" },
{ "高雄", "海鮮, 木瓜牛奶" },
{ "台南", "擔仔麵, 蝦捲" },
{ "台東", "池上便當, 原住民風味餐" }
};
[KernelFunction, Description("Search for food in a specific city.")]
public string GetFood([Description("city name for search food")] string city)
{
Console.WriteLine("GetFood"); // 用來觀察是否有呼叫到這個函數
if (_foodData.TryGetValue(city, out var food))
{
return food;
}
return "未知的城市";
}
}
// use Azure OpenAI
var builder = Kernel.CreateBuilder()
.AddAzureOpenAIChatCompletion(
endpoint: Config.aoai_endpoint,
deploymentName: Config.aoai_deployment,
apiKey: Config.aoai_apiKey);
builder.Plugins.AddFromType<FoodPlugin>();
Kernel kernel = builder.Build();
OpenAIPromptExecutionSettings settings = new() { ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions };
Console.WriteLine(await kernel.InvokePromptAsync("台北有什麼好吃的", new(settings)));
Console.WriteLine(await kernel.InvokePromptAsync("高雄推薦吃什麼好呢", new(settings)));
輸出結果:
GetFood
台北有許多美食,其中包含:
- 牛肉麵 - 一碗熱騰騰的牛肉麵,湯頭濃郁,配上軟嫩的牛肉,讓人回味無窮。
- 鼎泰豐小籠包 - 這裡的小籠包皮薄餡多,湯汁豐富,是來台北必試的美食之一。
如果需要更多建議或特定類型的食物,隨時告訴我!
GetFood
在高雄,您可以嘗試以下美食:
- 海鮮:高雄擁有新鮮的海鮮,種類繁多,特別推薦當地的漁市場。
- 木瓜牛奶:這是一種受歡迎的飲品,口感濃厚,非常消暑。
如果您有其他偏好的食物類型或特定的餐廳需求,可隨時告訴我!
總結來說,Function 的設計策略應視需求情境與外部系統資料而定。一次性回傳所有資料的方式簡單直接,適合資料量較小且資料變化頻率不高情境;而根據參數條件調用資料的策略則是靈活性大,能夠針對需求精確篩選資料,適合大型資料集或需要高準確性的應用。開發者在選擇策略時,應權衡效率、精確性與系統負載,找到最合適的解決方案。